fix(mocks): implement indexer in generated mock (#5676)#5683
Conversation
Mocking an interface that declares an indexer produced CS0535 because the generated MockImpl class declared the interface in its base list but MockImplBuilder skipped indexer emission entirely. The contract was therefore unimplemented at compile time. Generate the indexer in the impl (interface, partial, and wrap variants) and route both get and set through the engine using the index parameters (plus value, for setters) as the matchable arguments. Also surface configuration and verification via Item(args)/SetItem(args, value) extensions on Mock<T>, returning the existing MockMethodCall<T> / VoidMockMethodCall types so distinct index values produce independent setups and counts. Restores the T14 KitchenSinkEdgeCases case that was previously skipped.
GeneratePartialIndexer and GenerateWrapIndexer were 95% identical -- only
the fallback target differed (`base[...]` vs `_wrappedInstance[...]`). Both
now delegate to a shared GenerateOverrideIndexer helper parameterised by
that target, and the manual `AppendLine("{") + IncreaseIndent` brace dance
inside the engine-miss fallback blocks is replaced with the standard
OpenBrace/CloseBrace pair.
No change to generated output; T14 + full Mocks/SourceGenerator/Analyzers
test suites still green.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| CodeStyle | 3 minor |
🟢 Metrics 24 complexity
Metric Results Complexity 24
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review: fix(mocks): implement indexer in generated mock (#5676)
Overall, this is a well-structured and complete fix. The approach of dispatching to dedicated GenerateInterfaceIndexer / GenerateOverrideIndexer methods — rather than simply un-skipping the existing property path — is the right call; indexers have different codegen shape (no named member, this[…] syntax, implicit value in setters) and would have needed if-guards throughout the property generator. The fallback semantics in GenerateOverrideIndexer cleanly mirror the existing override-property pattern.
Things done well
-
Fallback correctness. The try-then-fallback pattern in
GenerateOverrideIndexerfor non-abstract members (TryHandleCallWithReturn/TryHandleCall, then delegate tobase[…]or_wrappedInstance[…]) correctly mirrors the same pattern used in the existing override property helpers. -
Extension method naming. Choosing
Item(…)/SetItem(…, value)maps directly to theget_Item/set_ItemIL names used in the engine call strings. Consistent and unsurprising. -
Edge-case allocation. Using
Array.Empty<object?>()in the zero-parameter getter path (and for the matcher list) avoids a pointless allocation. The setter path always allocates (becausevaluemust be in there), which is correct. -
Test coverage. The two T14 tests cover: compile with a co-resident regular property, distinct-index independence, exact call-count verification, mixed literal / wildcard matchers, and setter verification. That's a solid regression baseline.
Issues and suggestions
1. XML doc comments on private methods — against project coding standards
GenerateInterfaceIndexer, GeneratePartialIndexer, GenerateWrapIndexer, and GenerateOverrideIndexer all carry multi-line <summary> blocks. CLAUDE.md is explicit: "Default to writing no comments. Only add one when the WHY is non-obvious." Private generator helpers with self-documenting names don't meet that bar; the /// <summary> blocks describe what the code does (already clear from the name) rather than why a non-obvious constraint exists. Removing them keeps the file consistent with the rest of the builder.
2. FormatIndexerParameterList silently drops parameter modifiers
private static string FormatIndexerParameterList(MockMemberModel indexer)
{
return string.Join(", ", indexer.Parameters.Select(p => $"{p.FullyQualifiedType} {p.Name}"));
}This only emits type name; it doesn't emit in, ref, or out modifiers. Indexers with in T key parameters (a valid C# pattern) would generate code that doesn't compile. Consider checking whether MockMemberModel.ParameterModel carries modifier information and forwarding it here, similarly to how the existing method-parameter formatting handles it. If parameter-with-modifiers indexers are genuinely impossible in the current model, a guard or comment is worthwhile so future model changes don't silently break this.
3. argsArray is computed unconditionally in two places
In GenerateInterfaceIndexer:
var argsArray = GetIndexerGetterArgsArray(prop); // always runs
// ...
if (prop.HasGetter)
{
writer.AppendLine($"get => ... {argsArray} ...");
}And identically in GenerateOverrideIndexer. If the indexer is setter-only, argsArray is built but never used. This is a very minor point (it's pure string work), but it's also trivially fixed by moving the variable inside the if (prop.HasGetter) block, which improves reading clarity (the reader sees the value and its use together).
4. Known limitation — typed wrapper not emitted for indexer interfaces
The PR description acknowledges MockWrapperTypeBuilder.CanGenerateWrapper still returns false for indexer-bearing interfaces. Worth tracking as a follow-up issue if it isn't already filed, because the IFoo_Mock typed wrapper is the primary ergonomic surface for most consumers; landing without it means indexer-bearing interface users get Mock<IFoo> which is functional but less convenient.
Snapshot tests
CLAUDE.md requires that changes to source generator output have their .verified.txt snapshots committed. The diff doesn't include snapshot files — assuming they were reviewed and are committed, this is fine. If they haven't been updated, running the snapshot-acceptance loop described in CLAUDE.md is needed before merge.
Verdict: The core implementation is correct and well-designed. The two issues worth addressing before merge are the XML doc comments (minor, style) and the missing parameter-modifier forwarding in FormatIndexerParameterList (correctness gap for a currently-rare but valid input). The unconditional argsArray is cosmetic only.
- Drop XML doc blocks from private indexer-emission helpers; the member names are self-describing per project standards. - Forward parameter modifiers in indexer signatures: discovery now uses GetParameterDirection() instead of hardcoding In, and FormatIndexerParameterList delegates to FormatParameterList so `in T key` indexers compile. - Move argsArray inside `if (HasGetter)` in interface and override indexer emitters; setters compute their own args. - Add T14b test exercising an `in` parameter on an indexer.
|
Applied review feedback in 88f7776. Applied
Test status (net10.0)
Deferred / out-of-scope
|
There was a problem hiding this comment.
Code Review: Implement Indexer in Generated Mock
Summary
This PR addresses #5676 by implementing full indexer support in the TUnit mocks code generator. Previously, interfaces with indexers were unmockable — the generator skipped them with if (prop.IsIndexer) continue;, producing CS0535 compilation errors. This PR:
- Implements indexer emission across three code paths (interface, partial, wrapper mocks)
- Adds
Item(...)/SetItem(..., value)extension methods inMockMembersBuilderfor fluent setup/verification - Restores previously-skipped tests (T14, T14b) in
KitchenSinkEdgeCasesTests.cs - Fixes parameter direction handling for indexer parameters (
inmodifiers)
Architectural Assessment: Well-Designed
Three-path dispatch strategy is correct. The PR correctly separates:
- Interface mocks — direct engine routing (no fallback needed since all members are abstract)
- Partial mocks — try-intercept with
basefallback - Wrapper mocks — try-intercept with
_wrappedInstancefallback
This mirrors exactly how methods and properties are handled, maintaining consistency. The try-intercept pattern allows non-mocked indices to fall through to real implementations, which is the correct semantic.
Extension method API is idiomatic. Exposing mock.Item(5).Returns("five") and mock.SetItem(5, "five").WasCalled() reuses MockMethodCall<T> and VoidMockMethodCall without modification — a strong sign the abstraction is being applied correctly rather than bent.
MemberDiscovery.cs fix is important. Replacing the hardcoded Direction = ParameterDirection.In with p.GetParameterDirection() was a correctness bug, not just a style issue. Without this, in-parameter indexers would be silently mishandled in generated code.
Issues Found
1. Multi-parameter indexer coverage gap (LOW risk, but worth noting)
There are no tests for indexers with multiple parameters (e.g., this[int x, int y]). The logic in GetIndexerGetterArgsArray() and GetIndexerSetterArgsArray() appears correct for this case — it iterates all parameters — but a test would confirm the generated Item(x, y) extension method compiles and dispatches correctly. Worth a follow-up issue if not in scope now.
2. Wrapper type generation remains disabled (acknowledged limitation)
MockWrapperTypeBuilder.CanGenerateWrapper still returns false for indexer-bearing interfaces. This is correctly acknowledged as out of scope. A follow-up issue tracking this would prevent it from being forgotten.
3. Static-abstract indexers remain unhandled (acknowledged limitation)
MockBridgeBuilder still skips static-abstract indexers. Also correctly scoped out.
What's Done Well
- The parameter direction fix in
MemberDiscovery.csis a genuine correctness improvement beyond just the feature work - Tests T14a/T14b/T14c cover the golden path, distinct-index isolation, wildcard matchers, and
inparameters - Generated code follows the same
HandleCallWithReturn/HandleCalldispatch pattern as existing methods — no new engine surface area needed - All 954+ existing tests continue to pass
Verdict
Approve. The implementation is sound, consistent with existing patterns, and well-tested for the primary use cases. The known limitations are properly scoped and acknowledged. The parameter direction fix is a bonus correctness improvement.
The only actionable suggestion is to consider opening follow-up issues for: (1) multi-parameter indexer tests, (2) wrapper type generation for indexer interfaces, so these don't get lost.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.37.10 to 1.39.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 Commits viewable in [compare view](thomhurst/TUnit@v1.37.10...v1.39.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
MockImplBuilder, replacing the threeif (prop.IsIndexer) continue;skip sites that were leavingIFoo.this[int]unimplemented and producing CS0535.Item(args)andSetItem(args, value)extension methods inMockMembersBuilderso per-index setups (Returns) and verifications (WasCalled) work via the existingMockMethodCall<T>/VoidMockMethodCallplumbing.T14kitchen-sink tests covering contract satisfaction, the regular-property co-resident path, per-index Returns/WasCalled, and setter verification with mixedAny<T>()/ literal matchers.Why
Any interface with an indexer was previously unmockable — even if the consumer only needed the other members. The full-support route was small (the indexer model was already discovered) and avoids shipping a stub that would have made
IList-style mocks throw at runtime.Known limitations
MockWrapperTypeBuilder.CanGenerateWrapperstill returnsfalsewhen an indexer is present, so the typedIFoo_Mockwrapper is not emitted for indexer-bearing interfaces. The baseMock<IFoo>API works fully; only the typed-wrapper sugar is unavailable. Out of scope for this PR.MockBridgeBuilderare still skipped (separate code path, no interface contract failure).Test plan
TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs) passTUnit.Mocks.Testsfull suite (net10.0): 954/954TUnit.Mocks.SourceGenerator.Tests(net10.0): 45/45TUnit.Mocks.Analyzers.Tests(net10.0): 30/30Closes #5676